Перейти к основному содержимому

5.14. Асинхронность

Разработчику Архитектору

Асинхронность

Что такое асинхронность

Асинхронность — это способ организации выполнения кода, при котором длительные операции не останавливают основной поток программы. Вместо того чтобы заставлять приложение ждать завершения задачи, система продолжает обрабатывать другие события: пользовательский ввод, анимации, обновления интерфейса. Это особенно важно в средах с графическим интерфейсом, где отзывчивость напрямую влияет на восприятие качества продукта.

В языке Swift асинхронность реализована на уровне языка через ключевые слова async и await, введённые начиная с версии 5.5. Эта модель заменяет устаревшие подходы, основанные на замыканиях и делегатах, и позволяет писать код, который выглядит как последовательный, но при этом эффективно использует ресурсы системы.

Основные принципы Swift Concurrency

Swift Concurrency — это встроенная система управления асинхронными задачами. Она включает в себя:

  • Асинхронные функции (async), которые могут приостанавливать своё выполнение.
  • Оператор await, указывающий точку возможной приостановки.
  • Задачи (Task), представляющие собой независимые единицы работы.
  • Акторы (actor), обеспечивающие безопасный доступ к общему состоянию.
  • Группы задач (TaskGroup), позволяющие управлять множеством параллельных операций.

Эти компоненты работают совместно, образуя целостную модель, в которой компилятор и среда выполнения контролируют корректность и производительность.

Объявление и вызов асинхронных функций

Функция становится асинхронной, когда в её сигнатуре появляется ключевое слово async:

func fetchData() async -> Data { ... }

Такая функция может содержать точки приостановки — места, где выполнение временно передаётся системе. Чтобы вызвать асинхронную функцию, необходимо использовать await:

let data = await fetchData()

Ключевое слово await допустимо только внутри контекста, который сам является асинхронным: либо в другой async-функции, либо в специальном асинхронном замыкании.

Этот подход сохраняет линейную структуру кода, избегая «ада колбэков» — ситуации, когда вложенные замыкания затрудняют чтение и отладку.

Управление задачами через Task

Task — это структура, которая представляет собой отдельную асинхронную операцию. Создание задачи позволяет запустить асинхронный код вне текущего потока выполнения:

let task = Task {
let result = await performLongOperation()
print(result)
}

Задачи могут быть отменены. Отмена не прерывает выполнение насильственно — она устанавливает флаг, который задача может проверить через Task.isCancelled или с помощью выбрасывания ошибки через Task.checkCancellation(). Это обеспечивает кооперативную отмену: задача сама решает, как корректно завершить работу.

Отмена особенно полезна при работе с интерфейсом: если пользователь покидает экран, все связанные с ним задачи можно отменить, предотвратив утечки памяти и ненужную работу.

Параллельное выполнение нескольких операций

Swift поддерживает одновременный запуск нескольких асинхронных операций. Для этого используются два основных механизма:

Конструкция async let

Позволяет запустить несколько операций параллельно и дождаться их результатов:

async let first = fetchUser()
async let second = fetchSettings()
async let third = fetchPreferences()

let user = await first
let settings = await second
let prefs = await third

Все три вызова начинаются сразу, а не последовательно. Это сокращает общее время выполнения, особенно при независимых сетевых запросах.

Группы задач (withTaskGroup)

Для динамического управления набором задач используется withTaskGroup:

await withTaskGroup(of: String.self) { group in
for id in [1, 2, 3, 4] {
group.addTask {
return await fetchItem(id: id)
}
}
for await result in group {
print(result)
}
}

Группы задач особенно эффективны при обработке коллекций, когда количество операций заранее неизвестно или зависит от условий выполнения.

Интеграция с существующими API

Многие системные фреймворки Apple уже поддерживают асинхронные методы. Например, URLSession предоставляет метод data(from:delegate:), помеченный как async throws. Это позволяет загружать данные без использования делегатов или замыканий:

let (data, response) = try await URLSession.shared.data(from: url)

Для совместимости со старыми API, основанными на замыканиях, Swift предлагает механизм continuation. С его помощью можно преобразовать callback-функцию в асинхронную:

func legacyFetch(completion: @escaping (Result<Data, Error>) -> Void)

func modernFetch() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
legacyFetch { result in
continuation.resume(with: result)
}
}
}

Это упрощает миграцию устаревшего кода и обеспечивает постепенный переход к современной модели.

Безопасность данных и акторы

При параллельном выполнении нескольких задач возникает риск гонок данных — ситуации, когда несколько потоков одновременно изменяют одно и то же значение. Swift решает эту проблему с помощью акторов.

Актор — это специальный тип, который гарантирует, что его состояние изменяется только одним потоком в каждый момент времени. Доступ к свойствам и методам актора из внешнего кода автоматически становится асинхронным:

actor Counter {
var value = 0
func increment() { value += 1 }
}

let counter = Counter()
await counter.increment()
print(await counter.value)

Эта модель исключает необходимость ручной синхронизации через блокировки или очереди, делая многопоточный код надёжным по умолчанию.


Асинхронность и жизненный цикл представлений в SwiftUI

В SwiftUI асинхронные операции тесно связаны с жизненным циклом представлений. Модификатор task позволяет запускать асинхронный код в момент появления представления на экране и автоматически отменять его при исчезновении:

struct UserProfileView: View {
@State private var user: User?

var body: some View {
VStack {
if let user {
Text(user.name)
}
}
.task {
user = await fetchCurrentUser()
}
}
}

Этот подход устраняет необходимость вручную отслеживать состояние представления или управлять отменой задач. Система гарантирует, что задача будет завершена корректно, даже если пользователь быстро покинет экран. Это предотвращает утечки памяти и ненужные сетевые запросы.

Если требуется повторный запуск задачи при изменении определённых условий, используется перегрузка task(id:). Задача пересоздаётся каждый раз, когда значение id меняется:

.task(id: userID) {
user = await fetchUser(by: userID)
}

Такой механизм идеально подходит для динамических интерфейсов, где данные зависят от внешнего состояния, например, от выбранного элемента списка.

Работа с Core Data в асинхронном контексте

Core Data — это фреймворк для управления объектной моделью данных. Он требует выполнения операций в правильном контексте, привязанном к определённому потоку. В асинхронной среде Swift это достигается с помощью perform или performAndWait, но начиная с iOS 15, Apple предоставила более удобные инструменты.

Метод perform можно обернуть в асинхронную функцию:

extension NSManagedObjectContext {
func perform<T>(_ block: @escaping (NSManagedObjectContext) async throws -> T) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
self.perform {
Task {
do {
let result = try await block(self)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
}

Теперь можно безопасно выполнять запросы к базе данных внутри await:

let users = try await context.perform { context in
let request = User.fetchRequest()
return try context.fetch(request)
}

Такой подход сохраняет целостность данных и совместим с общей моделью асинхронности Swift. Он также позволяет использовать акторы для изоляции контекста Core Data, обеспечивая полную потокобезопасность.

Обработка ошибок в асинхронном коде

Асинхронные функции в Swift могут быть помечены как throws, что позволяет им выбрасывать ошибки. Вызов такой функции требует использования try await:

do {
let data = try await fetchDataFromNetwork()
} catch NetworkError.timeout {
// Обработка таймаута
} catch {
// Обработка других ошибок
}

Ошибки распространяются по цепочке вызовов точно так же, как в синхронном коде. Это упрощает логику обработки исключений и делает её предсказуемой. Swift не разделяет ошибки на «проверяемые» и «непроверяемые» — любая ошибка может быть перехвачена, если это необходимо.

Важно помнить, что отмена задачи через Task.cancel() не приводит к выбрасыванию ошибки автоматически. Разработчик должен явно проверять флаг отмены и реагировать на него, обычно выбрасывая CancellationError:

func longOperation() async throws -> Result {
for i in 0..<1000 {
try Task.checkCancellation()
// Выполнение шага
}
return Result()
}

Функция Task.checkCancellation() выбрасывает CancellationError, если задача была отменена. Это позволяет интегрировать отмену в стандартную систему обработки ошибок.

Потоки значений: AsyncStream и AsyncThrowingStream

Для случаев, когда результат не единичный, а представляет собой последовательность значений во времени, Swift предлагает AsyncStream и AsyncThrowingStream. Эти типы позволяют создавать асинхронные последовательности, которые можно перебирать с помощью for await:

func temperatureUpdates() -> AsyncStream<Double> {
return AsyncStream { continuation in
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
let temp = readCurrentTemperature()
continuation.yield(temp)
}
continuation.onTermination = { _ in
timer.invalidate()
}
}
}

// Использование
for await temperature in temperatureUpdates() {
print("Текущая температура: \(temperature)")
}

Потоки особенно полезны для работы с сенсорами, WebSocket-соединениями, уведомлениями или любыми источниками данных, которые генерируют значения непрерывно. Они интегрируются с системой отмены: при отмене задачи вызывается замыкание onTermination, что позволяет освободить ресурсы.

Повторяемые задачи и долгоживущие операции

Некоторые задачи должны выполняться периодически или оставаться активными в течение всего времени работы приложения. Для этого Swift предоставляет Task.sleep и возможность создавать бесконечные циклы с проверкой отмены:

Task {
while !Task.isCancelled {
await performBackgroundSync()
try await Task.sleep(for: .seconds(30))
}
}

Такой подход безопасен, потому что Task.sleep является точкой приостановки, и система может приостановить выполнение без блокировки потока. При переходе приложения в фон режим или при завершении работы задача будет отменена, и цикл прекратится.

Для более сложных сценариев, таких как фоновая загрузка или обработка уведомлений, рекомендуется использовать комбинацию асинхронных задач с системными API, например, BGProcessingTask или UNNotificationServiceExtension, чтобы соблюдать ограничения операционной системы.


Сравнение async/await и Grand Central Dispatch

Grand Central Dispatch (GCD) — это низкоуровневая система управления параллелизмом, существующая в экосистеме Apple с 2009 года. Она предоставляет очереди (DispatchQueue), группы (DispatchGroup), семафоры и другие примитивы для организации многопоточного выполнения. GCD остаётся актуальной технологией, особенно в случаях, требующих тонкого контроля над потоками или интеграции с C-совместимыми API.

Swift Concurrency, напротив, представляет собой высокоуровневую модель, встроенную в язык. Она абстрагирует детали управления потоками и вместо этого оперирует понятиями задач, акторов и точек приостановки. Это снижает когнитивную нагрузку и устраняет целый класс ошибок, связанных с гонками данных и неправильной синхронизацией.

Переход от GCD к async/await не означает отказ от очередей. Наоборот, Swift Concurrency использует диспетчеры, которые могут быть привязаны к конкретным очередям. Например, для выполнения кода на главном потоке используется MainActor:

@MainActor
func updateUI() { ... }

Любой вызов такой функции из асинхронного контекста автоматически переносится на главный поток. Это заменяет конструкцию DispatchQueue.main.async, делая код чище и безопаснее.

В то же время, если требуется выполнить синхронную блокирующую операцию — например, вызов сторонней библиотеки без поддержки асинхронности — её можно обернуть в Task.detached или использовать withCheckedContinuation совместно с GCD:

func legacyBlockingCall() -> Data { ... }

func modernAsyncCall() async -> Data {
return await withCheckedContinuation { continuation in
DispatchQueue.global().async {
let result = legacyBlockingCall()
continuation.resume(returning: result)
}
}
}

Такой подход позволяет постепенно мигрировать кодовую базу, сохраняя совместимость и получая преимущества новой модели.

Производительность и профилирование асинхронных задач

Асинхронные задачи в Swift не создают по одному потоку на каждую операцию. Вместо этого система использует пул потоков и кооперативную многозадачность: задача добровольно отдаёт управление в точках await, позволяя другим задачам выполняться на том же потоке. Это значительно снижает накладные расходы по сравнению с традиционными потоками.

Для анализа производительности асинхронного кода рекомендуется использовать Instruments, в частности шаблон Swift Concurrency. Он визуализирует жизненный цикл задач, показывает точки приостановки, время выполнения и возможные блокировки. Это помогает выявить «висячие» задачи, избыточные переключения контекста или неэффективные цепочки вызовов.

Особое внимание следует уделить задержкам между await. Если между двумя точками приостановки выполняется длительная синхронная операция, она может блокировать поток, даже если сама функция объявлена как async. Такие участки кода называются синхронными хвостами и нарушают принцип отзывчивости. Их следует выносить в отдельные задачи или оборачивать в фоновые очереди.

Тестирование асинхронного кода в XCTest

Тестирование асинхронных функций в Swift стало значительно проще благодаря поддержке async/await в XCTest. Методы тестов могут быть объявлены как async, и внутри них допустимо использовать await:

func testFetchUser() async throws {
let user = try await userService.fetchUser(id: 123)
XCTAssertEqual(user.name, "Timur")
}

Для проверки отмены задач используется XCTExpectation в сочетании с ручным управлением временем или моками. Также доступен метод fulfill() для явного подтверждения завершения асинхронной операции.

Если тестируемый код зависит от времени — например, содержит Task.sleep — его следует заменить на внедряемую зависимость, чтобы избежать реальных задержек в тестах. Это достигается через протоколы или замыкания:

struct TimeProvider {
var sleep: (Duration) async -> Void = { duration in
await Task.sleep(for: duration)
}
}

// В тесте:
let mockTimeProvider = TimeProvider(sleep: { _ in /* ничего не делать */ })

Такой подход делает тесты быстрыми, детерминированными и независимыми от системных условий.

Практические паттерны асинхронного программирования

Загрузка изображений с кэшированием

Асинхронная загрузка изображений — типичный сценарий в мобильных приложениях. Эффективная реализация включает три этапа: проверка кэша в памяти, проверка кэша на диске, сетевой запрос. Все этапы могут быть объединены в одну async-функцию:

func loadImage(for url: URL) async throws -> UIImage {
if let image = memoryCache[url] { return image }
if let image = try diskCache.loadImage(for: url) {
memoryCache[url] = image
return image
}
let data = try await URLSession.shared.data(from: url).0
let image = UIImage(data: data)!
memoryCache[url] = image
try diskCache.save(image, for: url)
return image
}

Такой код легко читается, не содержит вложенных замыканий и корректно обрабатывает ошибки.

Retry-логика с экспоненциальной задержкой

При работе с сетью часто требуется повторять запрос в случае временного сбоя. Асинхронность упрощает реализацию стратегии повторных попыток:

func fetchWithRetry<T>(_ operation: @escaping () async throws -> T, maxAttempts: Int = 3) async throws -> T {
var lastError: Error?
for attempt in 1...maxAttempts {
do {
return try await operation()
} catch {
lastError = error
if attempt < maxAttempts {
let delay = Double(2 ^ (attempt - 1))
try await Task.sleep(for: .seconds(delay))
}
}
}
throw lastError!
}

Эта функция принимает любую асинхронную операцию и автоматически повторяет её с увеличивающейся задержкой. Такой подход универсален и легко тестируется.

Отмена при смене состояния

В интерфейсах, где пользователь может быстро менять параметры (например, поиск по мере ввода), важно отменять предыдущие запросы. Это достигается с помощью хранения ссылки на текущую задачу:

@State private var activeSearchTask: Task<Void, Never>?

func search(query: String) {
activeSearchTask?.cancel()
activeSearchTask = Task {
let results = await performSearch(query: query)
if !Task.isCancelled {
self.results = results
}
}
}

Проверка Task.isCancelled перед обновлением состояния предотвращает применение устаревших данных.